事件循环

# 事件循环

JS自诞生起就是一门单线程的非阻塞的脚本语言。

参考链接:事件循环机制的那些事 (opens new window)

[TOC]

# 一、预备概念

Stack, heap, queue

# 1.1 函数调用形成了一个栈帧

function foo(b) {
    var a = 10;
    return a + b + 11;
}

function bar(x) {
    var y = 3;
    return foo(x * y);
}

console.log(bar(7)); // 返回 42
1
2
3
4
5
6
7
8
9
10
11

当调用 bar 时,创建了第一个帧 ,帧中包含了 bar 的参数和局部变量。当 bar 调用 foo 时,第二个帧就被创建,并被压到第一个帧之上,帧中包含了 foo 的参数和局部变量。当 foo 返回时,最上层的帧就被弹出栈(剩下 bar 函数的调用帧 )。当 bar 返回的时候,栈就空了。

# 1.2 对象被分配在一个堆中

堆,即用以表示一大块非结构化的内存区域。

# 1.3 一个待处理的消息队列

一个 JavaScript 运行时包含了一个待处理的消息队列。每一个消息都关联着一个用以处理这个消息的函数。

在事件循环期间的某个时刻,运行时从最先进入队列的消息开始处理队列中的消息。为此,这个消息会被移出队列,并作为输入参数调用与之关联的函数。正如前面所提到的,调用一个函数总是会为其创造一个新的栈帧。

函数的处理会一直进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个消息(如果还有的话)。

  • 执行至完成

每一个消息完整的执行后,其它消息才会被执行。这为程序的分析提供了一些优秀的特性,包括:一个函数执行时,它永远不会被抢占,并且在其他代码运行之前完全运行(且可以修改此函数操作的数据)。

这个模型的一个缺点在于当一个消息需要太长时间才能处理完毕时,Web应用就无法处理用户的交互,例如点击或滚动。浏览器用“程序需要过长时间运行”的对话框来缓解这个问题。一个很好的做法是缩短消息处理,并在可能的情况下将一个消息裁剪成多个消息。

# 1.4 单线程

# 1.4.1 理解单线程

  • JS的单线程并不是指整个JS引擎只有1个线程,而是指运行代码只有1个线程,但是它还有其他线程来执行其他任务。比如时间函数的计时、AJAX技术中的和后台交互等操作。

  • 所以,实际情况应该是:JS引擎中执行代码的线程开始运行代码,当执行到异步方法时,把异步的回调方法放入到队列中,然后由专门计时的线程开始计时。代码线程继续运行。如果计时的时间已到,那么它会通知代码线程来执行队列中对应的回调函数。当然前提是代码线程已经把同步代码执行完后,否则需要继续等待。

# 1.4.2 单线程的弱点

  1. 无法利用多核CPU。
  2. 错误会引起整个应用退出,应用的健壮性值的考验。
  3. 大量计算占用CPU导致无法继续调用异步I/O。

# 二、事件循环

  • 从全局任务 script开始,任务依次进入栈中,被主线程执行,执行完后出栈。
  • 遇到异步任务,交给异步处理模块处理,对应的异步处理线程处理异步任务需要的操作,例如定时器的计数和异步请求监听状态的变更。
  • 当异步任务达到可执行状态时,事件触发线程将回调函数加入任务队列,等待栈为空时,依次进入栈中执行。

先微后宏。

  • 由于执行代码入口都是全局任务 script,而全局任务属于宏任务,所以当栈为空,同步任务任务执行完毕时,会先执行微任务队列里的任务。
  • 微任务队列里的任务全部执行完毕后,会读取宏任务队列中排最前的任务。
  • 执行宏任务的过程中,遇到微任务,依次加入微任务队列。
  • 栈空后,再次读取微任务队列里的任务,依次类推。

perfect-event-ioop

  • 就像是一个容器,任务都是在栈中执行。
  • 主线程 就像是操作员,负责执行栈中的任务。
  • 任务队列 就像是等待被加工的物品。
  • 异步任务完成注册后会将回调函数加入任务队列等待主线程执行。
  • 执行栈中的同步任务执行完毕后,会查看并读取任务队列中的事件函数,于是任务队列的函数结束等待状态,进入执行栈,开始执行。

event-loop

之所以称之为事件循环,是因为它经常按照类似如下的方式来被实现:

while (queue.waitForMessage()) {
    queue.processNextMessage();
}
1
2
3

如果当前没有任何消息,queue.waitForMessage() 会同步地等待消息到达。

  • 用户交互、IO 和定时器会向事件队列中加入事件。
  • 并发是指两个或多个事件链随时间发展交替执行,以至于从更高的层次来看,就像是同时 在运行(尽管在任意时刻只处理一个事件)。

# 2.1 setTimeout

  • 函数 setTimeout接受两个参数:待加入队列的消息和一个延迟(可选,默认为 0)。
  • 这个延迟代表了消息被实际加入到队列的最小延迟时间。如果队列中没有其它消息,在这段延迟时间过去之后,消息会被马上处理。
  • 但是,如果有其它消息,setTimeout 消息必须等待其它消息处理完。因此第二个参数仅仅表示最少延迟时间,而非确切的等待时间
const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"));
const baz = () => console.log("Third");

bar();
foo();
baz();
// First Third Second
1
2
3
4
5
6
7
8

# 2.2 常见异步操作

  • Ajax

  • DOM的事件操作

  • setTimeout

  • Promise的then方法

  • Node的读取文件

# 2.3 任务

采纳 JSC 引擎的术语,我们把宿主发起的任务称为宏观任务,把 JavaScript 引擎发起的任务称为微观任务。

# 2.3.1 宏任务 (macrotask)

script(全局任务)setTimeoutsetIntervalsetImmediateI/OUI renderingscript(整体代码)

# 2.3.2 微任务(microtask)

process.nextTickPromise.then()Object.observeMutationObserverPromise

在微任务中 process.nextTick 优先级高于Promise

  • 如果是宏任务,则新增一个宏任务队列,任务队列中的宏任务可以有多个来源。
  • 如果是微任务,则直接压入微任务队列。

异步任务

  • Event loop过程:将一个macro-task执行并出队;将一队micro-task执行并出队;执行渲染操作,更新界面;处理worker相关的任务。

  • 渲染时机:在异步任务中实现DOM修改时,应该将其包装成micro任务。

// task 是一个用于修改DOM的回调
setTimeout(task, 0)
// 因为script是一个macro任务,所以执行完script就要去处理micro队列,接着就是render;然后本次render并没有执行task,所以没有渲染DOM。

Promise.resolve().then(task)
// 不用再等待一轮事件循环,就可以为用户呈现最及时的渲染结果。
1
2
3
4
5
6